TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import { EventRow } from "@/components/event-row";5import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score, Sparkline, StateBadge, Stat, TypeChip } from "@/components/ui";6import { ShareButton } from "@/components/watch-button";7import { api, SITE_URL, type ClusterDetail, type EventItem } from "@/lib/api";8import { fmtInt, fmtOffset, fmtScore, relTime, typeLabel, utcDate, utcDateTime, utcTime } from "@/lib/format";910export const dynamic = "force-dynamic";1112function num(v: number | string | null | undefined): number {13 if (v === null || v === undefined) return 0;14 const n = typeof v === "string" ? Number(v) : v;15 return Number.isFinite(n) ? n : 0;16}1718export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {19 const { slug } = await params;20 const d = await api.cluster(slug);21 if (!d) return { title: "Cluster not found" };22 const c = d.cluster;23 const desc = (c.summary?.trim() || `${c.event_count} signal${c.event_count === 1 ? "" : "s"} from ${c.source_count ?? 1} source${(c.source_count ?? 1) === 1 ? "" : "s"} — ${d.first_party_signals} first-party, ${d.external_signals} external. Propagation timeline with WebSensor lead time.`).slice(0, 200);24 const path = `/cluster/${c.slug ?? c.id}`;25 return {26 title: c.title,27 description: desc,28 alternates: { canonical: path },29 openGraph: { type: "article", title: c.title, description: desc, url: `${SITE_URL}${path}`, publishedTime: new Date(c.first_at).toISOString(), modifiedTime: new Date(c.last_at).toISOString(), tags: c.categories },30 twitter: { card: "summary", title: c.title, description: desc },31 };32}3334/** Signals per hour (or per day when the story spans more than 3 days) — computed from the events. */35function series(events: EventItem[], first: number, last: number): { values: number[]; unit: "hour" | "day" } {36 const span = Math.max(0, last - first);37 const unit: "hour" | "day" = span > 72 * 3600e3 ? "day" : "hour";38 const step = unit === "hour" ? 3600e3 : 86400e3;39 const start = Math.floor(first / step) * step;40 const n = Math.min(120, Math.floor((last - start) / step) + 1);41 const values = new Array<number>(Math.max(1, n)).fill(0);42 for (const e of events) {43 const i = Math.floor((new Date(e.detected_at).getTime() - start) / step);44 if (i >= 0 && i < values.length) values[i]! += 1;45 }46 return { values, unit };47}4849export default async function ClusterPage({ params }: { params: Promise<{ slug: string }> }) {50 const { slug } = await params;51 const d = await api.cluster(slug);52 if (!d) notFound();53 const c = d.cluster;54 const path = `/cluster/${c.slug ?? c.id}`;55 const lead = num(d.lead_time_ms ?? c.lead_time_ms);56 const velocity = num(c.velocity);57 const firstMs = new Date(c.first_at).getTime();58 const lastMs = new Date(c.last_at).getTime();59 const spanMs = Math.max(0, lastMs - firstMs);60 const sources = sourcesOf(d);61 const countryOf = (p: ClusterDetail["propagation"][number]): string | null => (p.source as { country?: string | null }).country ?? null;62 const spark = series(d.events, firstMs, lastMs);63 const primary = d.events.find((e) => e.id === c.primary_event_id) ?? d.events[0];64 const jsonLd = {65 "@context": "https://schema.org",66 "@type": "NewsArticle",67 headline: c.title,68 description: c.summary ?? undefined,69 datePublished: new Date(c.first_at).toISOString(),70 dateModified: new Date(c.last_at).toISOString(),71 url: `${SITE_URL}${path}`,72 mainEntityOfPage: `${SITE_URL}${path}`,73 author: { "@type": "Organization", name: "WebSensor", url: SITE_URL },74 publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL },75 about: d.entities.slice(0, 12).map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })),76 keywords: c.categories.join(", "),77 ...(primary?.url ? { isBasedOn: primary.url } : {}),78 };7980 return (81 <>82 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />83 <PageHeader84 compact85 kicker={86 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">87 <StateBadge state={c.state} />88 <span className="font-mono text-fg-subtle">CLUSTER</span>89 {d.entities.slice(0, 6).map((x) => (90 <Chip key={x.id} href={`/entity/${x.id}`} title={typeLabel(x.type)}>{x.name}</Chip>91 ))}92 {d.entities.length > 6 && <span className="font-mono text-fg-subtle tabular">+{d.entities.length - 6}</span>}93 {c.categories.slice(0, 3).map((k) => <Chip key={k} href={`/category/${k}`} className="!text-fg-subtle">{k}</Chip>)}94 </span>95 }96 title={c.title}97 description={c.summary?.trim() ? <span className="whitespace-pre-line">{c.summary.trim()}</span> : undefined}98 actions={99 <>100 <ShareButton path={path} />101 {primary && <Link href={`/event/${primary.slug}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Primary event →</Link>}102 </>103 }104 />105106 <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-fg-muted">107 <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.event_count)}</span> signal{c.event_count === 1 ? "" : "s"}</span>108 <span className="text-fg-subtle">·</span>109 <span><span className="font-mono font-semibold text-signal tabular">{fmtInt(d.first_party_signals)}</span> first-party</span>110 <span className="text-fg-subtle">·</span>111 <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(d.external_signals)}</span> external</span>112 <span className="text-fg-subtle">·</span>113 <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.source_count ?? sources.length)}</span> source{(c.source_count ?? sources.length) === 1 ? "" : "s"}</span>114 <span className="text-fg-subtle">·</span>115 <span className="font-mono text-[11.5px] text-fg-subtle tabular" title={`${utcDateTime(c.first_at)} → ${utcDateTime(c.last_at)}`}>{utcDateTime(c.first_at).replace(" UTC", "")} → {utcDate(c.last_at) === utcDate(c.first_at) ? utcTime(c.last_at) : utcDateTime(c.last_at).replace(" UTC", "")} UTC</span>116 <span className="text-fg-subtle">·</span>117 <span className="text-fg-subtle">updated {relTime(c.last_at)}</span>118 </div>119120 {lead > 0 && (121 <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 border-l-2 border-l-signal bg-signal-soft/50 px-3 py-2 text-[12.5px]" role="note">122 <span className="font-mono text-[10.5px] font-semibold tracking-wider text-signal">WEBSENSOR LEAD TIME</span>123 <span className="text-fg">124 detected <span className="font-mono font-semibold text-signal tabular">{fmtOffset(lead).replace("+", "")}</span> before the first external report125 </span>126 {c.first_party_at && c.first_external_at && (127 <span className="font-mono text-[11px] text-fg-subtle tabular">first-party {utcTime(c.first_party_at, false)} · external {utcTime(c.first_external_at, false)} UTC</span>128 )}129 </div>130 )}131132 <div className="mb-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-4">133 <div className="bg-panel"><Stat label="Peak signal" value={fmtScore(Math.max(c.max_importance, ...d.events.map((e) => e.signal_score ?? 0)))} hint={`importance ${fmtScore(c.max_importance)}`} tone={c.max_importance >= 80 ? "hot" : undefined} /></div>134 <div className="bg-panel"><Stat label="Velocity" value={velocity > 0 ? fmtScore(velocity) : "0"} hint={velocity >= 40 ? "signals accumulating fast" : "signals per unit of time"} tone={velocity >= 40 ? "warn" : undefined} /></div>135 <div className="bg-panel"><Stat label="Span" value={fmtOffset(spanMs).replace("+", "")} hint={`${utcTime(c.first_at, false)} → ${utcTime(c.last_at, false)} UTC`} /></div>136 <div className="bg-panel"><Stat label="Lead time" value={lead > 0 ? fmtOffset(lead).replace("+", "") : "—"} hint={lead > 0 ? "first-party before external" : d.external_signals === 0 ? "no external report yet" : lead < 0 ? `external first by ${fmtOffset(-lead).replace("+", "")}` : "n/a"} tone={lead > 0 ? "signal" : undefined} /></div>137 </div>138139 <div className="grid gap-4 xl:grid-cols-[1fr_340px]">140 <div className="flex min-w-0 flex-col gap-4">141 <Panel title={<>Web velocity · propagation <span className="font-mono text-fg-subtle">{d.propagation.length}</span></>} dense action={<span className="hidden items-center gap-3 font-mono text-[10.5px] text-fg-subtle sm:inline-flex"><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-signal" /> first-party</span><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-line-strong" /> external</span></span>}>142 {d.propagation.length === 0 ? (143 <Empty>No propagation recorded yet — the cluster has a single signal.</Empty>144 ) : (145 <ol className="divide-y divide-line">146 {d.propagation.map((p, i) => (147 <li key={p.id} className={`grid grid-cols-[4.25rem_1fr] gap-x-3 border-l-2 px-3 py-2 sm:grid-cols-[4.25rem_1fr_auto] ${p.first_party ? "border-l-signal" : "border-l-line-strong"}`}>148 <div className="flex flex-col font-mono text-[12px] leading-4 tabular">149 <span className={`font-semibold ${i === 0 ? "text-fg" : p.first_party ? "text-signal" : "text-fg-muted"}`}>{i === 0 || p.offset_ms === 0 ? "00:00" : fmtOffset(p.offset_ms)}</span>150 <time dateTime={p.at} title={utcDateTime(p.at)} className="text-[10.5px] text-fg-subtle">{utcTime(p.at)}</time>151 </div>152 <div className="min-w-0 sm:col-start-2">153 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">154 <Link href={`/source/${p.source.id}`} className="font-mono font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{p.source.name}</Link>155 {countryOf(p) && <Flag code={countryOf(p)} />}156 <span className="sm:hidden"><Score value={p.importance} size="sm" /></span>157 <Badge kind={p.first_party ? "first-party" : "external"} compact />158 {p.sensor?.type && <Chip className="font-mono" title={p.sensor.name}>{p.sensor.type}</Chip>}159 <TypeChip type={p.event_type} />160 </div>161 <Link href={`/event/${p.slug}`} className="mt-0.5 block font-medium leading-snug text-fg hover:underline">{p.title}</Link>162 </div>163 <div className="hidden items-start pt-0.5 sm:flex"><Score value={p.importance} /></div>164 </li>165 ))}166 </ol>167 )}168 </Panel>169 <Panel title={<>Signals <span className="font-mono text-fg-subtle">{d.events.length}</span></>} dense action={primary && <Link href={`/event/${primary.slug}`} className="text-[11px] text-fg-subtle hover:text-fg">primary event →</Link>}>170 {d.events.length ? d.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No signal in this cluster.</Empty>}171 </Panel>172 </div>173 <aside className="flex min-w-0 flex-col gap-4">174 <Panel title={spark.unit === "hour" ? "Signals per hour" : "Signals per day"}>175 <Sparkline values={spark.values} width={300} height={44} tone={c.state === "breaking" ? "hot" : "signal"} />176 <div className="mt-1 flex justify-between font-mono text-[10.5px] text-fg-subtle tabular">177 <span>{utcDate(c.first_at)} {utcTime(c.first_at, false)}</span>178 <span>peak {Math.max(0, ...spark.values)}/{spark.unit === "hour" ? "h" : "d"}</span>179 <span>{utcDate(c.last_at)} {utcTime(c.last_at, false)}</span>180 </div>181 </Panel>182 <Panel title={<>Entities <span className="font-mono text-fg-subtle">{d.entities.length}</span></>} dense>183 {d.entities.length === 0 ? (184 <Empty>No entity resolved for this cluster.</Empty>185 ) : (186 <ul className="divide-y divide-line">187 {d.entities.slice(0, 14).map((x) => (188 <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">189 <Link href={`/entity/${x.id}`} className="min-w-0 truncate font-medium hover:underline">{x.name}</Link>190 <span className="flex shrink-0 items-center gap-1.5"><Chip>{typeLabel(x.type)}</Chip><Score value={x.importance} size="sm" /></span>191 </li>192 ))}193 {d.entities.length > 14 && <li className="px-3 py-1.5 text-[11px] text-fg-subtle">+{d.entities.length - 14} more entities</li>}194 </ul>195 )}196 </Panel>197 <Panel title={<>Sources <span className="font-mono text-fg-subtle">{sources.length}</span></>} dense>198 {sources.length === 0 ? (199 <Empty>No source.</Empty>200 ) : (201 <ul className="divide-y divide-line">202 {sources.map((s) => (203 <li key={s.id} className="grid grid-cols-[1fr_auto_2rem] items-center gap-x-2 px-3 py-1.5 text-[12.5px]">204 <div className="min-w-0">205 <Link href={`/source/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link>206 <div className="flex items-center gap-1.5 font-mono text-[10.5px] text-fg-subtle"><span className="truncate">{s.domain}</span>{s.country && <Flag code={s.country} />}</div>207 </div>208 <Badge kind={s.first_party ? "first-party" : "external"} compact />209 <span className="text-right font-mono text-[12px] tabular text-fg-muted" title="Signals from this source">{s.n}</span>210 </li>211 ))}212 </ul>213 )}214 </Panel>215 <Panel title="Share">216 <div className="flex flex-wrap items-center gap-2">217 <ShareButton path={path} />218 <Link href={`/api/v1/clusters/${encodeURIComponent(c.slug ?? c.id)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 font-mono text-[11.5px] hover:border-line-strong">JSON</Link>219 </div>220 <p className="mt-2 break-all font-mono text-[10.5px] text-fg-subtle">{SITE_URL}{path}</p>221 </Panel>222 </aside>223 </div>224 </>225 );226}227228function sourcesOf(d: ClusterDetail): { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }[] {229 const m = new Map<string, { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }>();230 for (const e of d.events) {231 const id = e.source?.id ?? e.source_id;232 const cur = m.get(id);233 if (cur) cur.n += 1;234 else m.set(id, { id, name: e.source?.name ?? id, domain: e.source?.domain ?? "", first_party: e.first_party !== false, country: e.country ?? null, n: 1 });235 }236 for (const p of d.propagation) {237 if (!m.has(p.source.id)) m.set(p.source.id, { id: p.source.id, name: p.source.name, domain: p.source.domain, first_party: p.first_party, country: (p.source as { country?: string | null }).country ?? null, n: 1 });238 }239 return [...m.values()].sort((a, b) => Number(b.first_party) - Number(a.first_party) || b.n - a.n);240}241